Skip to content

fix: zera os vermelhos conhecidos do differential (12/13) e fixa o oráculo no primary - #14

Merged
filipeforattini merged 7 commits into
mainfrom
differential-cleanup
Sep 2, 2026
Merged

fix: zera os vermelhos conhecidos do differential (12/13) e fixa o oráculo no primary#14
filipeforattini merged 7 commits into
mainfrom
differential-cleanup

Conversation

@filipeforattini

Copy link
Copy Markdown
Member

Fecha 12 dos 13 vermelhos conhecidos do differential. O 13º (2794) fica com diagnóstico completo: não é bug, é uma porta de backend que falta.

A descoberta que organiza metade do trabalho

Seis dos treze não eram regressão do scriptc — era o oráculo seguindo o HOST. As três lanes de differential ainda resolviam o oráculo com nodeOracleExecutable(), que segue o host, e esta máquina roda Node 26. O .node-version do repositório fixa 24.15.0, que é também o primary do NODE_COMPAT_MATRIX — ou seja, esses seis só ficam vermelhos numa máquina que roda um Node diferente do que o repositório fixa.

O node-matrix.ts que acabou de entrar já descreve exatamente essa distinção, e a docstring dele diz o que fazer: um censo segue o host, mas um oráculo SEMÂNTICO fixa no primary, porque um binário compilado reproduz o comportamento observável de UM Node e não consegue reproduzir dois. A infra entrou; as lanes de differential é que ainda não tinham sido migradas.

Migrar as três (primaryOracleExecutable(NODE_COMPAT_MATRIX)) faz os seis passarem sem tocar em nenhuma fixture, e faz uma execução local reproduzir o oráculo do CI em vez de depender do Node que estiver instalado.

Cheguei primeiro pelo caminho errado — normalizei as seis fixtures para serem estáveis nos dois majors — e refiz depois de ler o node-matrix: aquela abordagem apagava justamente a metade version-dependent de cada asserção, que é o que fixa a semântica do primary. Fixar o oráculo preserva a cobertura inteira, e SCRIPTC_NODE_ORACLE continua sobrescrevendo — que é como se vai ATRÁS dessas divergências de propósito, em vez de tropeçar nelas.

Confirmado empiricamente em origin/main limpo, sem nenhuma mudança minha, com o oráculo no primary: exatamente esses seis passam, exatamente os seis bugs reais continuam falhando.

Tabela: fixture → causa-raiz → veredito

Fixture Causa-raiz Veredito
1746-stream-for-await read() sem size parou de concatenar o buffer interno (nodejs/node#60441, semver-major, 26.0.0); o async-iterator herda oráculo → fixado no primary
2813-readable-async-iterator-chunks mesma #60441 oráculo → fixado no primary
1640-fd-read-decode v26 valida position mesmo com janela de leitura vazia; v24 fazia curto-circuito antes oráculo → fixado no primary
2631-create-require builtinModules.length mudou de 72 para 66 oráculo → fixado no primary
2599-stream-arg-ladders v26 removeu o caso especial da encoding 'buffer'e revelou que não havia lowering geral de write(string, encoding) oráculo + bug realcorrigido
1967-namespace-alias-typeonly v26 removeu --experimental-transform-types, então o harness caía no hook tsc, que ELIDE o alias onde o transform nativo emitia var P = T e estourava oráculo → fixado no primary
2568-rest-spread-forward-dynamic ABI islandRest: o thunk boxed nunca montava o array de rest corrigido (C + LLVM)
2590-object-create-dynamic mesmo bug de ABI islandRest corrigido
2210-dyn-promise-crossing .finally descartava a promise do callback — rejeição escapava como unhandled corrigido
1120-web-globals-encoders três bugs nos nossos próprios web globals da island corrigido
2716-island-optional-string-method optChain jsval no emissor LLVM não tinha a forma de resultado UNION corrigido
2084-destructuring-primitive-sources mensagem de erro do quickjs-ng ≠ V8, em erro lançado DENTRO da island divergência documentada → fixa o TIPO do erro
2794-readline-async-iterator rl.nextLine só existe no backend Rust; o emissor C recusa de propósito documentado (issue abaixo)

Os bugs reais

ABI do islandRest (2568, 2590) — o mais grave. Uma assinatura island-rest SOletra seu parâmetro final (array do engine) na lista de params e é marcada rest. O thunk de chamada boxed lia as duas coisas literalmente: preenchia todo param posicionalmente — entregando ao slot final o primeiro argumento EXCEDENTE onde a closure espera o pacote — e ainda anexava um array dyn extra que o callee não tem parâmetro para receber. const f = (...args) => args.length; f(1, 2) num programa --dynamic estourava com expected number, got undefined, porque args era o número 1: uma arrow no topo do módulo é um global dyn, então a chamada passa por esse thunk e não pelo caminho direto. Todo idioma de rest-forwarding sob --dynamic quebrava assim. Corrigido no emissor C e no LLVM (o Rust já fatiava certo); 2859-island-rest-boxed-call.js fixa a ABI de forma mínima nas três lanes.

.finally sobre promise dyn (2210). O resultado do callback era descartado — atalho documentado no próprio código ("that refinement waits for a use"). Uma cleanup que REJEITA nunca chegava à cadeia: escapava da fiber de reação e o binário morria com unhandled rejection, onde o JS substitui a settlement. E não esperar uma cleanup que RESOLVE settlava a cadeia cedo demais, o que era a causa real de finally kept 7 passar na frente de finally ran — a ordem era sintoma do mesmo await faltando.

Web globals da island (1120). formDecode andava por CODE UNIT do UTF-16 e entregava surrogates soltos ao TextEncoder, então astral virava dois U+FFFD antes de chegar ao toString(); entries/keys/values/forEach tiravam snapshot, quando a iteração de pares do WebIDL é VIVA (índice posicional relendo a lista atual — as quatro escadas de mutação estavam erradas, forEach incluído); e btoa/atob lançavam Error carimbado em vez do DOMException que o próprio prelúdio já define. Nada sob vendor/ foi tocado — a política do vendor README proíbe, e o libqjs.a é cacheado por commit upstream.

optChain island → union (2716). O braço jsval do emissor LLVM só tinha resultado void e resultado do engine, e lançava InternalCompilerError no resto. Um passo que aterrissa de volta no mundo ESTÁTICO (flatValue(...)?.trim()) responde string | undefined. Ganhou as mesmas duas formas que o emissor C já tinha; um kind não modelado agora vira LlvmUnsupportedError em vez de alegar bug do emissor.

write(string, encoding) em socket (2599). Só três formas eram lowered; qualquer outra encoding literal caía na cerca "write with 2 arguments" e o programa nem compilava. Uma encoding que o Node não conhece agora é o ERR_UNKNOWN_ENCODING síncrono dele. Spellings conhecidas mas ainda não lowered (hex, base64) mantêm a cerca em vez de escrever bytes errados. A fixture ganhou um degrau para isso ao lado do 'buffer', que fica.

2084 é divergência de motor, não bug: o erro é lançado dentro do quickjs-ng, que escreve not a number onde o V8 escreve Number.prototype.toFixed requires that 'this' be a Number. O vendor não é nosso para alinhar, então a fixture passa a fixar o TIPO do erro (que é o contrato real) e a limitação está escrita nos dynamic-tier limits.

Estado do gate: 13 vermelhos herdados do main

O differential C completo termina com 14 falhas: 2794 (acima) e 13 que já estão vermelhas no main, todas verificadas em origin/main limpo, sem nenhuma mudança minha, contra o Node que o repositório fixa.

Sete regridem em e313ba28. Bisect com build + differential em cada ponto:

commit resultado
c6aef3e3 7 passam
f7fa8e2f 7 passam
e313ba28 feat: bridge Rust dynamic module interfaces 7 falham
b58549fe16268662 7 falham

São 1559-conditional-spread-index-merge, 1562-optional-chain-tails, 1575-unknown-assert-into-record, 1576-width-coercions, 2047-objlit-accessors-shapes, 2464-qs-require-forms e 2678-util-parseargs. O sintoma é sempre leitura de propriedade virando undefined (app: dev 5173app: none 0; 127.0.0.1 3000 yesundefined undefined undefined). Não é sensibilidade de versão: falham também com o oráculo no primary. e313ba28 mexeu em lowering do frontend (lower-calls.ts, lower-exprs.ts, lower-island-interface.ts) numa feature mirada no Rust e regrediu a lane C de referência.

Quatro são de tuplas/destructuring: 540-tuples-basics, 1432-destructured-params, 1572-promise-reject-all-tuple, 2575-string-destructuring-decl. Falham nos dois oráculos, e no main atual sem nada meu.

Duas são de stream, e valem uma olhada à parte: 2845-readable-paused-read-boundaries e 2846-readable-unshift-order falham contra o primary — e passam contra Node 26. Elas fixam a semântica NOVA do read(): depois de read(3) devolver hel, o read() seguinte responde lo (só o resto do primeiro chunk) em vez do lo world que o Node 24 concatena. Isso expõe uma incoerência interna do runtime: o async-iterator concatena (semântica pré-26, o que 1746/2813 fixam) enquanto o read() pausado devolve um chunk por vez (semântica 26). As duas já estão vermelhas contra o Node que o repositório fixa; o oráculo seguindo o host é que escondia isso. Não mexi — escolher qual das duas semânticas o runtime implementa é decisão de vocês, e é a mesma decisão do follow-up abaixo.

Nenhum desses 13 é regressão deste PR.

Issue: portar rl.nextLine para o runtime C

2794 não é regressão — é cobertura de backend faltando, e o emissor C já declara isso no código ("The native async-iterator slice currently belongs to the Rust runtime"). for await (const line of rl) baixa para o libCall rl.nextLine (Promise<string | undefined>), implementado só em backend/rust/readline.ts. A lane LLVM falha pelo mesmo erro porque cai no fallback C — então uma implementação em C resolve as duas lanes.

O que falta:

  1. packages/runtime/src/scr_readline.c — o slot de callback pendente de question tem quase a forma certa, mas falta o modo "próxima linha OU fim": hoje scr_rl_settle_close DESCARTA um callback pendente (a question do Node nunca responde), enquanto nextLine precisa resolver undefined no fim do stdin, e resolver de novo em toda chamada seguinte para o laço for await terminar. Uma linha já bufferizada tem de responder na hora, como question faz via scr_rl_drain.
  2. packages/compiler/src/backend/emission/emit-exprs.ts — o caso rl.nextLine cria scr_promise_new() e passa um adaptador internado por union que monta string | undefined e cumpre a promise. O precedente exato já existe: scr_promise_race_add(result, p, &adapter) com E.raceAdapterFor.

O risco não é o tamanho, é a corretude assíncrona: manter o event loop vivo enquanto um nextLine está pendente, resolver undefined exatamente uma vez no EOF, e crlfDelay: Infinity. Preferi deixar diagnosticado a entregar meia implementação de I/O assíncrono.

Follow-up: decidir a semântica de read()

O read() sem size concatena o buffer inteiro no caminho do async-iterator — semântica pré-26, e a que a documentação do Node ainda descreve — mas o caminho pausado devolve um chunk por vez, semântica da #60441. Como o binário reproduz UM Node, essa divisão é um bug em si, independente de qual lado se escolha: 1746/2813 fixam um lado e 2845/2846 o outro.

Gates

gate resultado
differential C completo 1245 passam, 14 falham = 13 herdadas do main + 2794
as 12 fixtures da missão (lane C) 12/12 passam
LLVM -t nas corrigidas + 2716 13/13 passam
rust-differential -t nas tocadas 7/7 passam
cargo test (runtime-rust) 139 passam
pnpm lint 0 erros
pnpm build limpo

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bab3v6PNzMBUq7nJhLR8i7

An island-rest signature SPELLS its trailing engine-array parameter, so
its params list already carries that jsval slot AND the type is marked
rest. The dyn-boxed call thunk read both literally: it filled every param
positionally — handing the trailing slot the first surplus ARGUMENT where
the closure expects the pack — and then appended an extra dyn rest array
the callee has no parameter for.

So `const f = (...args) => args.length; f(1, 2)` in a --dynamic .js
program threw "expected number, got undefined": a module-level arrow is a
dyn global, so the call routes through this thunk rather than the direct
path, and `args` was bound to the number 1. Every rest-forwarding and
engine-value-through-rest idiom failed the same way (2568's very first
call, 2590's Object.create prototype).

The thunk now fills only the LEADING params positionally and builds the
trailing slot with scr_jsval_rest_from_dyn — the surplus dyn arguments
marshalled into one fresh engine array, the same pack the direct call
builds inline (jsOp arrLit) and isl_hostfn_invoke builds for a closure
entering the island as a host function.

Fixed in the C and LLVM emitters; the Rust backend already sliced the
trailing slot off correctly. 2859 pins the ABI minimally on all three
lanes.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
The LLVM emitter's jsval-receiver optChain arm handled only a void body
and an engine-valued result, and threw an InternalCompilerError for
anything else. A chain step that lands back in the STATIC world —
`flatValue(text, key)?.trim()`, a package's optional string through an
island handle — answers `string | undefined`, so 2716 could not compile at
all on the LLVM lane.

Give the arm the same two shapes the C emitter already has: an engine
result takes the engine's undefined cell, a union result takes that
union's interned undefined arm. An unmodelled result kind now raises
LlvmUnsupportedError (a backend-coverage fence) rather than claiming an
emitter bug.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
…tcome

`.finally` over a checked-dynamic promise dropped its callback's result
outright — a documented shortcut ("a finally callback returning a promise
would delay adoption; that refinement waits for a use"). Two things
followed in 2210:

- A cleanup promise that REJECTED never reached the chain. The rejection
  escaped the reaction fiber entirely and the binary died reporting an
  unhandled rejection, where JS replaces the source settlement with it —
  so `.finally(() => cleanupFails()).catch(...)` never fired.
- Not awaiting a cleanup that FULFILLS also settled the chain too early,
  so `finally kept 7` overtook a longer chain's `finally ran`. The
  ordering was a symptom of the same missing await, and falls out with it.

The reaction now walks a promise result the way the .then arm already
does: a cleanup rejection rejects dst (dropping the source's caught
record), a fulfillment is discarded and the source settlement passes
through, and a non-thenable result behaves exactly as before.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
Three independent divergences in the island's own web globals, all read by
corpus 1120 through __island_eval against Node's real implementations.

Astral percent-encoding. formDecode walked the raw query by UTF-16 CODE
UNIT and handed each one to TextEncoder, so a non-BMP character arrived as
a lone high surrogate then a lone low surrogate and became two U+FFFD
before ever reaching toString() — `new URLSearchParams('x=<U+1F600>')`
serialized as %EF%BF%BD%EF%BF%BD instead of %F0%9F%98%80. The literal
branch now pairs a high surrogate with its low surrogate; a genuinely lone
surrogate still replaces. (The sequence-init path was already correct,
which is why only the parsed spelling failed.)

Pair-iteration liveness. entries/keys/values were generators over
this._pairs and forEach iterated a slice() — both snapshots. WebIDL pair
iteration is LIVE: it holds the object plus a positional index and
re-reads the current list each step, so appending from a forEach callback
re-enters for the new tail, a mid-iteration delete skips forward over the
hole, and a mid-iteration sort can re-yield a pair that moved past the
cursor. All four of 1120's mutation ladders were wrong, forEach included.

btoa/atob rejections. The prelude's own invalidChar built a plain Error
and stamped .name, leaving .code undefined and `instanceof DOMException`
false where Node answers InvalidCharacterError with the legacy code 5. It
now throws the DOMException the same prelude already defines, matching the
static tier's scr_btoa/scr_atob.

The Rust island twin (island_web.js) already carried the surrogate pairing
and the DOMException; it gets the liveness fix so both islands stay
behaviourally identical. No vendored file is touched.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
2084 printed the message of a TypeError thrown INSIDE the island engine.
quickjs-ng words its engine-internal errors its own way ("not a number"
where V8 writes "Number.prototype.toFixed requires that 'this' be a
Number"), so that text can never match the Node oracle.

It is also not ours to align: the vendored engine is an unmodified
upstream snapshot by policy, and its prebuilt libqjs.a is cached by
upstream commit, so a local edit to quickjs.c would not even key the cache
correctly. Pin the error TYPE instead, which is the actual contract — the
receiver rules reject identically on both sides.

Documented in the dynamic-tier limits: match on error type, not message
text.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
The three differential lanes still resolved their oracle with
nodeOracleExecutable(), which follows the HOST. node-matrix.ts landed the
distinction they need and says so in its own docstring: a census follows
the host, but a SEMANTIC oracle pins to the primary, because a compiled
binary reproduces one Node's observable behavior and cannot reproduce two.

On a Node 26 host that mismatch turned six corpus programs red for reasons
that say nothing about the compiler:

- 1746 and 2813 — read() with no size stopped concatenating the internal
  buffer in 26.0.0 (nodejs/node#60441, semver-major), and the async
  iterator inherits it.
- 1640 — v26 validates `position` even when the read window is empty;
  v24 short-circuited first.
- 2631 — builtinModules.length moved from 72 to 66.
- 2599 — v26 dropped stream_base's 'buffer' encoding special case.
- 1967 — v26 removed --experimental-transform-types, so the harness fell
  back to the tsc hook, which ELIDES an import= alias of an uninstantiated
  namespace where the native transform emitted `var P = T` and threw.

All six pass unchanged against the primary. Pinning keeps their assertions
intact rather than deleting the version-dependent half of each one, and
SCRIPTC_NODE_ORACLE still overrides — which is how you go looking for
these divergences deliberately instead of tripping over them.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
socket.write(string, encoding) lowered only three shapes: the literal
'buffer' (Node's stream_base special case), the utf8 spellings, and a
Buffer chunk that ignores the encoding. Every other literal encoding fell
through to the "write with 2 arguments" fence, so a program Node answers
with a plain TypeError failed to compile at all.

An encoding Node does not know is its synchronous ERR_UNKNOWN_ENCODING,
raised before anything is written. Known but not-yet-lowered spellings
('hex', 'base64', ...) keep the fence rather than silently writing the
wrong bytes.

2599 gains a rung for it beside the existing 'buffer' one, which stays.

Claude-Session: https://claude.ai/code/session_0197JoEpMBBqqkSiBb2vNX5A
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant